#!/bin/sh
# Enterprise Onion Toolkit

# A NOTE TO CONTRIBUTORS: yes this is a big script, but it's also
# written to be easy to understand (and reason about) what it is
# doing; this is why there are `for` loops around `scp` rather than
# tarballing up a bunch of file and shipping them to remote machines,
# unpacking them there. This is (mostly) why there are some
# long_variable_names. I plan to keep it that way.

#rsync_flags="-n" # testing

# expected by tools and libraries
cd `dirname $0` || exit 1
export EOTK_HOME=`pwd`

# for invoking myself
prog=`basename $0`
self=$EOTK_HOME/$prog

# meta
version=1.5.0

# set project directory; this path is hard-replicated elsewhere
project_dir=$EOTK_HOME/projects.d

# mirror directory
mirrors_dir=$EOTK_HOME/mirrors.d

# onionbalance directory
ob_dir=$EOTK_HOME/onionbalance.d
ob_conf=$ob_dir/config.yaml
ob_status_sock=$ob_dir/ob-status.sock
ob_tor_conf=$ob_dir/tor.conf
ob_tor_control_sock=$ob_dir/tor-control.sock

# what are the hostnames of the remote workers?
host_list=$EOTK_HOME/eotk-workers.conf # <--------------------------- HOST FILE

# where we put the persistent, shared config
site_conf=$EOTK_HOME/eotk-site.conf

# init script name
init=eotk-init.sh
housekeeping=eotk-housekeeping.sh

# log retention
log_retain=8

# hostname for log purposes
hostname=`uname -n`

# set path
export PATH=$EOTK_HOME/opt.d:$EOTK_HOME/lib.d:$EOTK_HOME:$PATH

# ------------------------------------------------------------------

# argument stripping
flag_remote=false
flag_local=false
flag_boot=false
propagate_flags=""
while : ; do
    case "x$1" in
        x--local) ## [command] ... | runs commands locally, ignoring onionbalance if configured
            flag_local=true
            propagate_flags="$propagate_flags $1"
            shift
            ;;
        x--boot) ## [command] ... | used by startup scripts
            flag_boot=true
            propagate_flags="$propagate_flags $1"
            shift
            ;;
        x--remote) ## [command] ... | used during remote execution on workers
            flag_remote=true
            propagate_flags="$propagate_flags $1"
            shift
            ;;
        x--debug) ## [command] ... | enables execution tracing
            set -x
            shift
            ;;
        *)
            break
            ;;
    esac
done

CloudHosts() {
    # use --remote hack to forcibly stop risk of recursion...
    if $flag_remote ; then
        echo "localhost" # this will be treated as a magic sentinel
        return 0
    fi

    # perversely, --local, exactly the same
    if $flag_local ; then
        echo "localhost"
        return 0
    fi

    # if --boot, we want localhost IFF we would have it anyway...
    if $flag_boot ; then
        if [ -s $host_list ] ; then # does it contain localhost...?
            egrep '^(localhost)\b' $host_list
        else
            echo "localhost"
        fi
        return 0
    fi

    # todo: if hosts-file exists,
    # cat it (strip comments?)
    # and return
    if [ -s $host_list ] ; then
        cat $host_list
        return 0
    fi

    # else we are working just on this machine
    echo "localhost" # this will be treated as a magic sentinel
}

cloud_hosts=`CloudHosts` # saves multiple invocations
need_to_run_locally=false
for host in $cloud_hosts ; do
    test "x$host" = "xlocalhost" && need_to_run_locally=true
done

# delete dangerous stuff on boot
OnBootCleanup() {
    pidfiles=`find $EOTK_HOME -type f -name "*.pid"`
    sockfiles=`find $EOTK_HOME -type s -name "*.sock"`
    for file in $pidfiles $sockfiles ; do
        if [ -e $file ] ; then # -e => "exists"
            rm $file || exit 1
        fi
        Warn purged $file
    done
}

# ------------------------------------------------------------------

# print a formatted message to stdout
Print() {
    echo "$prog:" "$@"
}

# print a formatted message to stderr
Warn() {
    Print "$@" 1>&2
}

# compress logfiles
LogRotate() {
    SUFFIX=.bz2
    do_proj=$1 # true|false
    do_ob=$2 # true|false
    WHERE=""
    $do_proj && WHERE="$WHERE $project_dir"
    $do_ob && WHERE="$WHERE $ob_dir"
    if [ "x$WHERE" = "x" ] ; then
        Print error: LogRotate has no targets
        exit 1
    fi
    logfiles=`find $WHERE -type f -name "*.log" -print`
    for logfile in $logfiles ; do
        hi=`expr $log_retain - 1`
        dst=$logfile.$hi$SUFFIX
        if [ -f $dst ] ; then
            rm $dst || exit 1
            Print purged $dst
        fi
        while [ $hi -gt 0 ] ; do
            lo=`expr $hi - 1`
            src=$logfile.$lo$SUFFIX
            dst=$logfile.$hi$SUFFIX
            if [ -f $src ] ; then
                mv $src $dst || exit 1
                Print bumped $dst
            fi
            hi=$lo
        done
        dst=$logfile.$hi
        mv $logfile $dst || exit 1
        Print created $dst
        to_compress="$to_compress $dst"
    done
    if $do_proj ; then
        $self --local nxreload -a
        $self --local torreload -a
    fi
    if $do_ob ; then
        $self ob-restart -a
    fi
    if [ "x$to_compress" != "x" ] ; then
        Print $hostname COMPRESSING LOGS: patience please, this may take several minutes...
        ls -l $to_compress
        bzip2 -fv $to_compress
    fi
}

# essentially the projects.d folder is a little database
ListProjects() {
    if [ x$1 = x--softmap-only ] ; then
        softmap_only=true
    else
        softmap_only=false
    fi
    (
        test -d $project_dir || exit 1
        cd $project_dir || exit 1
        for d in *.d/ ; do
            if $softmap_only ; then
                test -f $d/softmap.conf || continue
            fi
            echo `basename $d .d`
        done
    )
}

# TODO(alecm) resolve potential clashes between project names and
# various other directory names ending in ".d"; maybe use ".proj"?

SpotPush() { # push any file named FOO from $EOTK_HOME and/or projects.d
    for filename in "$@" ; do # GENERATE/USE ABSOLUTE PATHS
        files=`find $project_dir -type f -name "$filename" | sort`
        # special: check top-level dir, too...
        tldfile=${EOTK_HOME}/$filename # USE ABSOLUTE PATHS
        test -f $tldfile && files="$files $tldfile"
        for host in $cloud_hosts ; do
            test "x$host" = "xlocalhost" && continue # skip self
            echo :::: push $host $filename ::::
            for filepath in $files ; do
                Print pushing $host:$filepath
                scp -p $filepath $host:$filepath || exit 1
            done
        done
    done
}

# push eotk directory to remote
DestructivePush() {
    for host in $cloud_hosts ; do
        test "x$host" = "xlocalhost" && continue # skip self
        echo :::: rnap $host ::::
        rsync $rsync_flags \
              -av \
              --delete \
              --delete-excluded \
              --exclude="*.log" \
              --exclude="*.pid" \
              --exclude="*.sock" \
              --exclude="*.yaml" \
              --exclude="*~" \
              --exclude="docs.d/" \
              --exclude="mirrors.d/" \
              --exclude="onionbalance.d/" \
              --exclude="secrets.d/" \
              --exclude=".git/" \
              --exclude=".gitignore" \
              --exclude="cached-certs" \
              --exclude="cached-microdesc*" \
              --exclude="configure*.log" \
              --exclude="eotk-workers.conf" \
              --exclude="hostname" \
              --exclude="lock" \
              --exclude="onion_service_non_anonymous" \
              --exclude="private_key" \
              --exclude="state" \
              ${EOTK_HOME}/ \
              $host:${EOTK_HOME}/
    done
}

# mirror remote back for log review, backup, etc
Mirror() {
    for host in $cloud_hosts ; do
        test "x$host" = "xlocalhost" && continue # skip self
        echo :::: mirror $host ::::
        test -d $mirrors_dir || mkdir -p $mirrors_dir || exit 1
        chmod 700 $mirrors_dir || exit 1
        rsync $rsync_flags -av \
              --delete \
              --delete-excluded \
              --exclude="cached-certs" \
              --exclude="cached-microdesc*" \
              $host:${EOTK_HOME}/ $mirrors_dir/$host/
    done
}

# run a command in the context of the local projects directory
RunLocally() {
    action=$1
    shift
    project=$1
    shift

    echo :::: $action $project $@ ::::
    sh "$project_dir/$project.d/$action.sh" "$@"
}

# $1=action, rest = project names
RunLocallyOverProjects() {
    action=$1
    shift # remaining arguments are projects

    if [ "x$1" = "x" ] ; then # test for no args
        Print error: missing project name, try: $prog projects for a list, or -a for all
        return 1
    elif [ "x$1" = "x-a" ] ; then # test for / expand the "-a" flag
        projects=`ListProjects`
    else # do what we are told
        projects="$*"
    fi

    # loop
    for project in $projects ; do
        RunLocally $action $project
    done
}

# run a command on remote machines, or possibly locally
InvokeRemotely() {
    for host in $cloud_hosts ; do
        test "x$host" = "xlocalhost" && continue # skip self
        echo :::: remote $host: $* ::::
        ssh "$host" "$self --remote $*"
    done
}

# get a config file and re/populate the projects directory with it
Configure() {
    log=configure$$.log

    # make the siteconf if it does not already exist
    test -f $site_conf || touch $site_conf || exit 1

    for file in "$@" ; do
        echo :::: configure $file ::::

        case "$file" in
            *.conf)
                : happy bunnies
                ;;

            *.tconf)
                file2=`basename $file .tconf`.conf
                if [ -s $file2 ] ; then
                    Print info: $file: using existing $file2
                else
                    Print info: Processing $file
                    Print info: Populating $file2 with onions
                    Print info: Please be patient...
                    expand-config.pl $file >$file2
                fi
                file="$file2"
                ;;

            *)
                Print error: bad config file suffix, was expecting: .conf, .tconf
                exit 1
                ;;
        esac

        if ! $EOTK_HOME/lib.d/do-configure.pl "$file" ; then
            Print error: failure processing $file: see $log
            exit 1
        fi
    done 2>$log

    Print done. logfile is $log.
}

# argument 'parser' - ha!

cmd="$1" # we may need the remaining args
test $# = 0 || shift # if no cmd-arg skip shifting, fall-thru

case "$cmd" in
    version|test) ## print version information for all tools
        test -d .git && git show -s --oneline
        tor --version || Warn cannot find tor executable
        nginx -v || Warn cannot find nginx executable
        onionbalance --version || Warn cannot find onionbalance executable
        Print $version $EOTK_HOME `uname -a`
        InvokeRemotely version
        ;;

    projects|proj) ## lists all projects
        ListProjects
        ;;

    configure|config|conf) ## file ... | use config file to rebuild project directories
        Configure "$@"
        ;;

    genkey|gen) ## generate an onion certificate and print its address
        secrets_dir=secrets.d
        test -d $secrets_dir || mkdir -p $secrets_dir || exit 1
        chmod 700 $secrets_dir || exit 1
        (
            cd $secrets_dir
            generate-onion-key.sh
        )
        ;;

    # ACTIONS

    start) ## project* ... | start the cited projects
        $flag_boot && OnBootCleanup
        $need_to_run_locally && RunLocallyOverProjects start "$@"
        InvokeRemotely start "$@"
        ;;

    stop) ## project* ... | stop the cited projects
        $need_to_run_locally && RunLocallyOverProjects stop "$@"
        InvokeRemotely stop "$@"
        ;;

    restart|bounce|reload) ## project* ... | restart the cited projects
        $need_to_run_locally && RunLocallyOverProjects bounce "$@"
        InvokeRemotely bounce "$@"
        ;;

    nxreload|nx-reload|ob-nxreload) ## project* ... | live-reload nginx configs for projects
        $need_to_run_locally && RunLocallyOverProjects nxreload "$@"
        InvokeRemotely nxreload "$@"
        ;;

    torreload|tor-reload) ## project* ... | live-reload tor configs for projects
        $need_to_run_locally && RunLocallyOverProjects torreload "$@"
        InvokeRemotely torreload "$@"
        ;;

    debugon|debug-on) ## project* ... | enable service debugging for projects
        $need_to_run_locally && RunLocallyOverProjects debugon "$@"
        InvokeRemotely debugon "$@"
        ;;

    debugoff|debug-off) ## project* ... | disable service debugging for projects
        $need_to_run_locally && RunLocallyOverProjects debugoff "$@"
        InvokeRemotely debugoff "$@"
        ;;

    cleanup) ## project* ... | stop and remove trash files for projects (eg: after crash, "nginx.pid exists!" and ".sock exists!" errors, etc)
        $need_to_run_locally && RunLocallyOverProjects cleanup "$@"
        InvokeRemotely cleanup "$@"
        ;;

    syntax) ## project* ... | perform nginx syntax check for projects
        $need_to_run_locally && RunLocallyOverProjects syntax "$@"
        InvokeRemotely syntax "$@"
        ;;

    harvest|onions) ## project* ... | list onions used for projects (including: used by onionbalance)
        $need_to_run_locally && RunLocallyOverProjects harvest "$@"
        InvokeRemotely harvest "$@"
        ;;

    status) ## project* ... | print eotk process status (known processes) for projects
        $self ob-ps # because common
        $need_to_run_locally && RunLocallyOverProjects status "$@"
        InvokeRemotely status "$@"
        ;;

    maps|map) ## project* ... | print onion mappings for projects (including: used by onionbalance)
        $need_to_run_locally && RunLocallyOverProjects maps "$@"
        InvokeRemotely maps "$@"
        ;;

    delete)
        Print $cmd not yet implemented, sorry.
        ;;

    # DIAGS

    ps) ## list all (probable) eotk processes on all workers
        $flag_remote || echo :::: eotk processes ::::
        ps auxww | egrep '\b(eotk|onionbalance|nginx)\b'
        # InvokeRemotely prints remote-ness banner diags
        InvokeRemotely ps
        ;;

    df) ## print filestore usage of eotk directory on all workers
        $flag_remote || echo :::: eotk filesystem space ::::
        df -akh $EOTK_HOME
        # InvokeRemotely prints remote-ness banner diags
        InvokeRemotely df
        ;;

    logsize|logsizes) ## list logfile sizes on all workers
        $flag_remote || echo :::: eotk log sizes ::::
        find $EOTK_HOME -name "*.log" -o -name "*.log.*" -ls
        # InvokeRemotely prints remote-ness banner diags
        InvokeRemotely logsize
        ;;

    # ---- ONIONBALANCE ----

    push)
        Print push is destructive and has been renamed, see the documentation
        ;;

    shutdown) ## shutdown all projects on all workers (and onionbalance)
        $self $propagate_flags ob-stop
        $self $propagate_flags stop -a
        ;;

    # this used to be called 'push' but got renamed because oops.
    # ---- DO NOT USE THIS CASUALLY, LEARN FROM MY MISTAKES ----
    ob-remote-nuke-and-push|rnap) ## DESTRUCTIVE PUSH of local project configuration to workers; THERE ARE NO SAFETY CHECKS, do not use on live system.
        $self stop -a
        DestructivePush
        ;;

    nxpush|ob-nxpush) ## push nginx config files to all workers
        # gently push all the nginx configs to the onionbalance workers; same as "spotpush nginx.conf"
        SpotPush nginx.conf
        ;;

    torpush|ob-torpush) ## push tor config files to all workers
        # gently push all the tor configs to the onionbalance workers; same as "spotpush tor.conf"
        SpotPush tor.conf
        ;;

    spotpush|ob-spotpush) ## file ... | push instances of files named <file> to all workers
        # gently push all instances of given filename, to onionbalance workers
        SpotPush "$@"
        ;;

    ob-config|obconfig|ob-configure|obconfigure)
        Print ob-configure had been rolled into ob-start
        Print please skip forward to: $prog ob-start project ...
        ;;

    ob-gather|obgather) ## onionbalance: gather all worker mappings, and build onionbalance configs; is automatically called by ob-start if needed
        # ob storage
        test -d $ob_dir || mkdir -p $ob_dir || exit 1
        chmod 700 $ob_dir # otherwise tor complains

        # sanity check that args are provided
        if [ "x$1" = "x" ] ; then # test for no args
            Print error: missing project name, try: $prog projects for a list, or -a for all "(if applicable)"
            exit 1
        elif [ "x$1" = "x-a" ] ; then # test for / expand the "-a" flag
            projects=`ListProjects --softmap-only`
        else # do what we are told
            projects="$*"
        fi

        tor_address=127.0.0.1
        tor_port=9055

        # get the mappings
        Print gathering mappings for OnionBalance for projects: $projects
        mappings=__om$$.tmp
        $self maps $projects |
            awk '/^::::/{next;} $4=="softmap"{print;}' >$mappings

        # check the mappings
        for p in $projects ; do
            n=`awk '$3=="'"$p"'"'<$mappings  | wc -l`
            if [ $n = 0 ] ; then
                Warn no mappings yet for project $p: does it exist / is it started / is it not a softmap project "?"
                rm $mappings
                exit 1
            else
                m=`awk '$3=="'"$p"'"{print $1}'<$mappings | sort -u | wc -l`
                w=`awk '$3=="'"$p"'"{print $6}'<$mappings | sort -u | wc -l`
                Print project $p contains $n mappings
                Print project $p uses $m master onions
                Print project $p uses $w worker onions
            fi
        done

        Print building OnionBalance configurations...
        (
            # notes/commentary, including onionbalance defaults, at:
            # https://media.readthedocs.org/pdf/onionbalance/latest/onionbalance.pdf
            echo LOG_LEVEL: info
            echo TOR_ADDRESS: $tor_address
            echo TOR_PORT: $tor_port
            echo STATUS_SOCKET_LOCATION: $ob_status_sock

            # REFRESH_INTERVAL How often to check for updated backend
            # hidden service descriptors. This value can be decreased
            # if your backend instance are under heavy loaded causing
            # them to rotate introduction points quickly. (default:
            # 600 seconds).
            refresh=600 # to be tuned
            echo REFRESH_INTERVAL: $refresh

            # INITIAL_DELAY How long to wait between starting
            # OnionBalance and publishing the master descrip- tor. If
            # you have more than 20 backend instances you may need to
            # wait longer for all instance descriptors to download
            # before starting (default: 45 seconds).
            echo INITIAL_DELAY: `expr $refresh / 6`

            # PUBLISH_CHECK_INTERVAL How often should to check if new
            # descriptors need to be published for the master hidden
            # service (default: 360 seconds).
            echo PUBLISH_CHECK_INTERVAL: `expr $refresh / 2`

            # DESCRIPTOR_OVERLAP_PERIOD How long to overlap hidden
            # service descriptors when changing descriptor IDs
            # (default: 3600 seconds)
            echo DESCRIPTOR_OVERLAP_PERIOD: `expr $refresh '*' 4`

            # DESCRIPTOR_UPLOAD_PERIOD How often to publish a
            # descriptor, even when the introduction points don't
            # change (default: 3600 seconds)
            echo DESCRIPTOR_UPLOAD_PERIOD: `expr $refresh '*' 4`

            # DESCRIPTOR_VALIDITY_PERIOD How long a hidden service
            # descriptor remains valid (default: 86400 seconds)
            echo DESCRIPTOR_VALIDITY_PERIOD: 86400 # 1 day

            do-obconfig.pl <$mappings
        ) > $ob_conf

        # clean up
        rm $mappings

        Print building OnionBalance-Tor configurations...
        (
            echo DataDirectory $ob_dir
            echo ControlPort unix:$ob_tor_control_sock
            echo PidFile $ob_dir/tor.pid
            echo "#" Log info file $ob_dir/tor.log
            echo Log notice file $ob_dir/tor.log
            echo SafeLogging 1
            echo HeartbeatPeriod 60 minutes
            echo RunAsDaemon 1
            echo "#" onionbalance
            # echo SocksPort unix:$ob_dir/tor-socks.sock # curl 7.38 does not like this
            echo SocksPort $tor_address:$tor_port
            echo CookieAuthentication 1
            # echo MaxClientCircuitsPending 1024
        ) > $ob_tor_conf
        ;;

    ob-start|obstart) ## start onionbalance (will gather and build configs if needed)
        # do not test for ob_dir here, it is okay to not exist

        if [ -f $ob_dir/ob.pid ] ; then
            Warn file $ob_dir/ob.pid already exists, OnionBalance may already be running.
            Warn Aborting...
            exit 1
        fi

        if [ -f $ob_dir/tor.pid ] ; then
            Warn file $ob_dir/tor.pid already exists, OnionBalance-Tor may already be running.
            Warn Aborting...
            exit 1
        fi

        # ob-gather checks args
        $self ob-gather "$@" || exit 1

        Print starting OnionBalance-Tor
        tor -f $ob_tor_conf >$ob_dir/tor-startup.log 2>&1

        Print starting OnionBalance
        onionbalance \
            -s $ob_tor_control_sock \
            -c $ob_conf </dev/null >$ob_dir/onionbalance.log 2>&1 & # bg
        ob_pid=$!
        echo $ob_pid >$ob_dir/ob.pid
        ;;

    ob-stop|obstop) ## stop onionbalance
        for pidfile in $ob_dir/ob.pid $ob_dir/tor.pid ; do
            test -s $pidfile || continue
            pid=`cat $pidfile`
            Print sending SIGTERM to $pid in $pidfile
            kill $pid
        done
        rm -f $ob_dir/ob.pid
        ;;

    ob-restart) ## restart onionbalance
        # ob-stop takes no args, but ob-start requires them; try to be consistent, therefore:
        if [ "x$1" = "x" ] ; then # test for no args
            Print error: missing project name, try: $prog projects for a list, or -a for all "(if applicable)"
            exit 1
        fi
        $self ob-stop
        $self ob-start "$@"
        ;;

    ob-ps) ## print list of onionbalance processes
        test -d $ob_dir || exit 0
        echo :::: onionbalance processes ::::
        pidfiles=`find $ob_dir -name "*.pid"`
        if [ "x$pidfiles" != "x" ] ; then
            ps -p `cat $pidfiles`
        fi
        ;;

    ob-status|obstatus) ## query onionbalance for its status
        $self ob-ps
        if [ -S $ob_status_sock ] ; then
            echo ""
            socat - unix-connect:$ob_status_sock
        fi
        ;;

    ob-maps|obmaps) ## project* ... | print onionbalance mappings for projects
        if [ "x$1" = "x" ] ; then # test for no args
            Print error: missing project name, try: $prog projects for a list, or -a for all "(if applicable)"
            exit 1
        fi
        $self maps "$@" |
            awk '/^::::/{next;} $4=="softmap"{print $1, $2}' |
            sort -k 2 -u
        ;;

    ob-watch|obwatch) ## use 'watch' to iterate 'ob-status'
        watch -n 5 $self ob-status
        ;;

    # FREEZE/BACKUP

    mirror|pull) ## pull a copy (rsync) of all workers to local 'mirrors.d' directory
        Mirror
        ;;

    backup|freeze) ## as 'mirror' but also create a datestamped compressed tarball once mirror is complete
        Mirror
        (
            ds=`date "+%Y%m%d%H%M%S"`
            cd $mirrors_dir || exit 1
            for directory in */ ; do
                test -d "$directory" || exit 1 # did */ expand?
                dir=`basename $directory` # strip trailing /
                echo :::: backup $dir ::::
                tar cf - $dir | bzip2 > $dir-$ds.tar.bz2
            done
        )
        ;;

    logrotate) ## compress logs and reload configurations on all workers
        InvokeRemotely logrotate
        # do we need to do onionbalance logs?
        if [ -d $ob_dir ] ; then
            need_to_do_ob_logs=true
        else
            need_to_do_ob_logs=false
        fi
        LogRotate $need_to_run_locally $need_to_do_ob_logs
        ;;

    script|scripts|make-script|make-scripts) ## create 'boot' and 'cron' housekeeping scripts
        for t in $init $housekeeping ; do
            lib.d/expand-template.pl templates.d/$t.txt </dev/null >$t 2>/dev/null
            chmod 755 $t
            Print created: $t
        done
        Print please read those files for installation instructions.
        ;;

    shell) ## run a shell in the eotk PATH environment
        env PS1='eotk-env$ ' bash -i
        ;;

    help|*) ## prints this text
        Print "switches and commands:"
        echo "  * project* => supports '-a' for all"
        echo "  * synonyms are in <angle-brackets|diamond-brackets>"
        echo ""
        exec ./lib.d/explain.pl $0
        exit 1
        ;;
esac

exit 0
